Skip to content

feat(ui): add the @lody/ui icon set - #701

Draft
wibus-wee wants to merge 27 commits into
feat/ui-v2from
feat/ui-icon-set
Draft

wibus-wee wants to merge 27 commits into
feat/ui-v2from
feat/ui-icon-set

Conversation

@wibus-wee

@wibus-wee wibus-wee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Related issue

Same-repository branch; no intake Issue.

Problem / pressure

Three sources of icons and no shared grid: packages/ui/src/internal/glyphs.tsx draws nine glyphs on a 16 grid at a 1.6 stroke because the package may depend on nothing but React, Base UI and StyleX; packages/components uses react-icons at sixteen sites (almost all brand marks) and inlines an <svg> in roughly four hundred files, each to whatever grid its author had. A menu row chevron and the sidebar toggle are two weights of two strokes on two grids, and every new surface draws a third. The product wants a set of its own, with the intermediate treatments (duotone and bulk) and with state: a sidebar that shows what is in it and whether it is open, animated between the two.

Summary

  • packages/ui/src/icons/registry.ts: 75 original icons on one 24 grid (20 live area, 1.5 stroke, round caps and joins, 2px container corners, 2px git nodes). Follows Nucleo drawing conventions, copies none of its paths. Each icon is marks (outline) plus, where it has depth, layers (mass / mid / front / detail / outer / dots).
  • icon.tsx: <Icon name variant title> and createIcon. Four treatments from the same paths: outline, duotone (mass at 18%), glyph (filled, marks cut through an SVG mask so the cut is a hole and not a painted panel colour), bulk (no stroke; mass 35%, mid 55%, front 100%). An icon states no size and no colour: it fills its box and inherits currentColor, the contract the existing glyphs have. Decoration unless title is given.
  • stateful.tsx: nine two-state icons (sidebar, chevron, check, eye, play/pause, star, bell, refresh, folder) driven by one registered custom property --lody-icon-t that CSS transitions; only transform, opacity and dash offset move, no path morphing; prefers-reduced-motion zeroes the duration.
  • index.ts: 75 named components (SidebarIcon, BranchIcon, ...), exported as @lody/ui/icons.
  • Gallery section (families, sizes 16/20/24/32, variant matrix, two-state pairs), test/icons.test.tsx, README row, AGENTS.md rule (existing bullets trimmed in wording only to stay under the 8 KiB gate), specs/ui-primitives.md Icons section (draft), proposed Agent Note.

Visual explanation

packages/ui/src/icons/
  registry.ts   ICONS[name] = { marks, layers? }   <- the drawing, generated from the design board
  icon.tsx      Icon / createIcon                   <- picks layers per variant
  stateful.tsx  *ToggleIcon, IconFrame              <- --lody-icon-t, CSS transition
  index.ts      75 named exports + re-exports
flowchart LR
  R[registry: marks + layers] --> O[outline: stroke marks]
  R --> D[duotone: mass 18% + marks]
  R --> G[glyph: mask cuts detail + front seam; outer strokes kept]
  R --> B[bulk: mass 35% / mid 55% / front 100%, no outline]
  S[stateful: one svg] -->|--lody-icon-t 0..1| T[transform / opacity / dashoffset]
Loading

Before / after

Before After
Nine 16-grid glyphs in @lody/ui, react-icons and ~400 ad hoc inline SVGs in components One 75-icon set on a 24 grid in @lody/ui/icons, four treatments per drawing, nine animated two-state icons
A filled icon is a second drawing A filled icon is a layer treatment of the same paths; the glyph cut is a mask, correct on any background
Open / closed sidebar are two icons swapped One svg whose divider, dashes and chevron are functions of one number

Test plan

  • pnpm --filter @lody/ui typecheck: clean.
  • pnpm --filter @lody/ui test: 21 files, 261 tests pass (17 new in test/icons.test.tsx: registry shape and grid bounds, a11y contract, the four treatments, mask uniqueness and name-in-id, outer strokes surviving a glyph, bulk without outline, two states as one markup with one number changed).
  • oxlint on the new and changed files: 0 errors.
  • pnpm run docs check: only the four pre-existing acp-extension-dsh broken links remain.
  • Rendered the React output of every icon in every variant in headless Chromium, light and dark: glyph cuts are transparent, bulk faces read as depth.
  • Static Storybook board opened in Chromium (light and dark): the section renders as intended, glyph cuts are transparent, and the --lody-icon-t transition interpolates live (see the PR comment for the computed values). jsdom applies no CSS, so tests cover only the two ends. check:public-boundary failed on uninitialised submodules in this worktree, unrelated to this diff.

Context handoff

Instructions for reviewing agents

  • Review focus: icon.tsx glyph mask (id from useId plus the icon name; strokes outside the mass drawn outside the mask), and stateful.tsx (CSS.registerProperty guard, StyleX values using var(--lody-icon-t), reduced-motion media query).
  • Decisions to challenge: an icon states no size or colour of its own; variants as layer treatments rather than separate drawings; a bell that rings by a sin of the same number rather than a keyframe; the AGENTS.md trims must not have changed any rule.
  • Plausible failures / evidence gaps: the transition is verified on the board but not by tests (jsdom has no CSS); sin() in calc() and registered-property transitions need Chromium, Safari 16.4 or Firefox 128; the 16px rendering is the 24 grid scaled, not an optically corrected small size.

Authoring context

  • User goal / directives: an original icon set for Lody in the manner of Nucleo, with outline, duotone, glyph and bulk treatments, more detail (sidebar with rows and an open/closed state) and animation, landed in the repository now without migrating callers.
  • Constraints / non-goals: @lody/ui depends only on React, Base UI and StyleX; no Nucleo paths (paid library, no files in hand, redistribution understood to be forbidden); no product caller migrates; glyphs.tsx untouched.
  • Risk-bearing decisions: a new public export path @lody/ui/icons; a module-level CSS.registerProperty call guarded for SSR and repeat registration.
  • Destructive or irreversible behavior: none; additive.
  • Deliberately not done or tested: no caller migration, no optically corrected 16px family, no Spinner from refresh.
  • Unknowns / confidence: high for the static set and the four treatments (rendered and tested); high for the transitions after driving one on the board in Chromium; other engines unverified.

🤖 Generated with Claude Code

Innei and others added 13 commits September 9, 2026 15:24
* feat(ui): add @lody/ui with StyleX tokens and Base UI Button

Introduce packages/ui as the Base UI + StyleX home for shared primitives.
It ships colour, scale, shadow and motion tokens tuned to the two palettes
the app renders (Lody Light and Vesper), squircle per-size radii, forced
light/dark themes, and a Button with primary/secondary/ghost/destructive/link
variants, mini..large sizes, tone, pill shape and icon layout.

Wire the StyleX unplugin into the Electron renderer, site-docs and Storybook,
pin one cascade layer order (theme, base, stylex, components, utilities) so
Tailwind preflight sits below component styles and utilities above them,
and inject the dev stylesheet into the TanStack Start root head.

Replace every packages/components Button import and buttonVariants user with
@lody/ui/button, delete the Radix button, and give AlertDialog action and
cancel their children back with a variant prop instead of red utilities.

Model: claude-fable-5-1
Claude-Session: https://claude.ai/code/session_01KJL8hsY2LscFcvxHFDzwxt

* fix(ui): use button tokens for composer actions

Model: gpt-6

---------

Co-authored-by: Wibus Wu <62133302+wibus-wee@users.noreply.github.com>
Rebuild the design reference from PR #305 inside the repository. The board
lived at an authenticated Claude artifact URL that cannot be opened from a
checkout or reviewed in a diff, so the visual system had no in-repo authority.

`packages/ui/src/gallery` renders every semantic colour, elevation rung,
shadow, radius, control size, type step, space step, constant and Button state
from the tokens themselves, once per palette in labelled side-by-side panels.
The value under each sample is read back off the rendered node with
getComputedStyle, so a token that changes its value shows the new one without
the board being edited. `packages/components` hosts it as the Design System /
UI Gallery story on the existing Storybook; no new dependency or dev server.

Building it exposed a defect in ThemeRoot: the button's colour tokens are
declared once at the document root, and a custom property resolves at its
declaration site, so a palette forced on a subtree left buttons carrying the
root palette. Vesper rendered light secondary and icon buttons.
`buttonPaletteTheme` re-declares those tokens on the element that carries the
palette, and ThemeRoot and forcedThemeClassNames apply it with each palette.

The gallery test walks the keys of the token objects, so a token with no
sample fails the package's tests.

Model: claude-opus-5

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(ui): add the @lody/ui field primitives

Only Button had migrated into @lody/ui, so every remaining control in
packages/components/src/ui still read Tailwind field concepts the new token
rules define no equivalent for: input-border, input-field, input-placeholder,
ring, muted. Those three files already disagreed with each other, carrying
three disabled treatments (bg-muted plus 60%, 70%, and the rules' 45%) and two
focus rings. Migrating Input, Textarea and Label one at a time across that gap
would have let each control invent its own state colours and fork the token
system the migration exists to unify.

Add one `field` component token group covering the label, the control, the help
text and the error, and a Base UI Field composition on top of it. Field.Root
owns name, disabled and validity; Field.Label, Input, Textarea,
Field.Description and Field.Error read that state through Base UI's className
callback instead of taking their own copies, so a caller states a field is
invalid in one place and cannot leave a control and its message disagreeing.
Input is Base UI's Input, Textarea is the same Field.Control rendered as a
textarea because Base UI has no textarea part, and Field.Label replaces
@radix-ui/react-label. src/field/well.ts holds the resting, ring and disabled
appearance the family shares so the controls that migrate next cannot each grow
their own.

The old-to-new mapping and the rest, placeholder, focus, invalid, disabled and
selected states are written into src/tokens/RULES.md, and the gallery gains the
composition, both controls, all three sizes and every state in both palettes.
ThemeRoot now applies a list of component palette themes rather than one, which
is the general mechanism the token gallery note left open until a second
colour-valued group existed.

Building this exposed that the desktop shell ends its Tailwind base layer with
`*:focus, *:focus-visible { outline: none !important }`. Layer priority is
reversed for important declarations, so no StyleX rule can win: a focused
Button with :focus-visible matching reports outline-style none and paints no
ring today. The field ring is therefore a box-shadow composed with the well's
inset shadow, which the same base layer cannot override, and that is now the
package rule. Button is left alone because its ring would have to be restated
per variant; it keeps the suppressed outline until a follow-up either gives it
the box-shadow ring or scopes the global reset.

No caller moved. 93 Input, 30 Textarea and 67 Label uses across 71 files still
import the Radix and Tailwind versions, and those files are deleted only when
their in-repo callers reach zero.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): give field controls the control type step

The field family set its own text at weight 400 with no token behind the
disabled dim, which reads as a second opinion next to the type rule rather than
an application of it. src/tokens/RULES.md says controls are 13 at weight 500
with text.controlTracking, and Button already renders that way, so an input and
a button placed next to each other disagreed on the same step.

Set the shared well to weight 500 so every control in the family inherits the
control step; the size style still owns the size, and the tracking was already
there. Add field.disabledOpacity so the 45% dim is one value the control and its
label read instead of the same literal written in two files, and point the
dimensions that land on the space scale at it (space.2, space.3, space.1.5).
The 10px inline padding of the 32px control stays a literal because the scale
has no half step between 8 and 12. Rename paddingYBlock to paddingBlock, which
said the same thing twice. The gallery's focus stand-in picks up the same weight
and tracking so it keeps matching the control it stands in for.

Measured in Chromium in both palettes: every control now reports 13px at weight
500 with -0.13px tracking, 12px at the 28px step, the label 12px at 500, and the
disabled control and its help text 0.45.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(components): move every field caller onto @lody/ui

The field primitives landed with nothing using them: 93 Input, 30 Textarea and
67 Label uses across the app still imported the Radix and Tailwind versions, so
the token mapping was proven on the gallery board rather than in the product,
and the two implementations of a text field stayed side by side.

Point every caller in packages/components and site-docs at @lody/ui. Label
becomes Field.Label, imported as UiField wherever a surface already had a local
Field of its own, so the label now carries the field's disabled state instead of
peer-disabled guesswork. Delete input.tsx, label.tsx and textarea.tsx with their
barrel exports and drop @radix-ui/react-label, which is the package rule that a
Radix file goes once its in-repo callers reach zero. site-docs drops its ambient
@/ui/textarea declaration for the same reason.

Spending the contract on real call sites found two holes in it. Textarea
accepted no style: its props were built by omitting style from <textarea> the
way Base UI does, but Base UI re-adds it and this did not, so the message editor
could not set its own font size. PasswordInput typed its props from a raw
<input>, so the HTML size attribute collided with the token step of the same
name; a wrapper around a primitive follows the primitive's props. form.tsx also
kept typing FormLabel from @radix-ui/react-label while rendering a Base UI
label, which is now typed from the element it renders.

pnpm typecheck passes across the workspace, @lody/ui keeps its 24 tests, and
oxlint, the platform and public boundary guards, lint:i18n and docs check are
clean. The lockfile carries only the removed dependency.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): one type step for the whole control ladder

Control text was sized by step: 12 at 28, 13 at 32 and 36. That reads as a
typographic preference, but it is wrong for this system specifically.

These rules give each channel one job and then forbid a second. `accent` marks
live state and is never a button fill; `separator` divides rows and never wraps
a surface; disabled is an opacity and not a colour. Type size is already spoken
for the same way, as a role rather than a density: controls 13, prose 14, labels
and help 12. That is the type axis restating what the colour axis says with
label, secondaryLabel and tertiaryLabel, the thing versus about the thing.
Density already owns the 28 / 32 / 36 height and the radius that compensates for
it, so letting size drive type as well is the channel overloading the rest of
the document bans.

The collision was visible in the tokens: field.textSmall and field.labelSize
both resolved to footnoteSize, so at the 28 step a control's own value was
typographically indistinguishable from the label describing it, in a system
whose first colour rule is that those are different things.

Give the ladder one step and one token to carry it. field.text and button.text
replace the per-size text tokens, because tokens whose values happen to match
are an invitation to drift apart later. Button's small moves from 12 to 13: a
compact button and a dialog button are the same command at two densities and
should not disagree about how their label is set. Button's mini keeps 12 and is
left alone; at 24px it is not on the ladder these rules define, and whether it
belongs there or with the 16px things the radius rule mentions is a separate
question. RULES loses the sentence sanctioning 12 at the 28px step, which was
documentation written to justify the implementation.

Measured in Chromium in both palettes: button and input at 28, 32 and 36 all
report 13px at weight 500 with -0.13px tracking and radius 8 / 10 / 10, mini
stays 24px at 12px and radius 6, and the field label is now visibly smaller than
the value inside its control.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): give Button a focus ring the shell cannot suppress

Button asked for its focus ring with an `outline`, and the product shell ends
its Tailwind base layer with `*:focus, *:focus-visible { outline: none
!important }`. Layer priority is reversed for important declarations, so no
ordering lets a StyleX rule win: a focused Button with :focus-visible matching
reported outline-style none and painted nothing. Keyboard users had no focus
indicator on any button in the desktop app, and the package's own rule already
said a ring is a box-shadow, which left Button as its only violator.

Compose the ring into each variant's box-shadow. It has to be restated per
variant rather than added by a second class, because CSS cannot append to a
box-shadow list and each variant owns a different edge: primary and destructive
an ink highlight, secondary a raised shadow, ghost and link nothing. button.ring
and button.ringWidth carry it, and ring joins the palette theme so it resolves
against a forced subtree rather than the document root.

:active stays none, which is the press the rules describe, translate 1px and
drop the edge. Putting the ring there instead would draw it on an ordinary mouse
press, since a pointer press matches :active while :focus-visible stays false.
The cost is a keyboard-held press losing the ring while the key is down, which
is the smaller of the two errors.

The board gains a focus row for Button beside the one the field already had,
built from the same tokens and reading its shadow back off the node.

Measured in Chromium in both palettes: all five variants report the accent ring
composed with their own edge on keyboard focus, rgb(93, 141, 239) in Lody Light
and rgb(255, 199, 153) in Vesper. A mouse press reports box-shadow none with
translateY(1px) and no ring, and the edge returns on release.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): stop Field parts crashing outside a root, and ring on aria-invalid

Two defects, both found by review rather than by the tests.

Base UI's Field Label, Description and Error call useFieldRootContext(false),
whose optional flag is false, so they throw without a Field.Root; only Control
passes optional and survives alone. The takeover note had this backwards, and
the caller migration was written against the wrong reading: all 25 files that
render UiField.Label do so with no root, including the login page, every
settings panel, onboarding and several dialogs. Loading the UI/Switch story
reproduced it, a blank surface and Base UI error #28, so this was a crash on the
way to shipping rather than a theoretical gap.

Those three parts now fall back to a plain label, p and div when no root is
above them, styled identically and still honouring render through Base UI's
useRender. A label with htmlFor is meaningful on its own, and the only thing
lost outside a field is field state, of which there is none there. Wrapping 25
surfaces in a root instead was rejected: that reshapes the DOM and layout of
files this work should not be touching, to work around a primitive that should
not crash.

The invalid ring also read validity only from Base UI's state.valid, so a
caller marking its own control, as agent-role-form does, put aria-invalid on the
DOM and got no ring: a screen reader announced invalid while nothing looked
wrong. Field.Root renders its validity as aria-invalid, so the attribute is not
a second source of truth but the rendered form of the one truth. src/field/
invalid.ts reads it for Input and Textarea, and every ARIA value except false
counts, grammar and spelling included. StyleX has no attribute selector, so the
read happens in JS rather than in a style condition.

Tests cover both: the parts rendering outside a root with matching classes, and
the ring appearing for each ARIA value that means invalid and staying away for
the ones that do not. All three ring tests were confirmed to fail against the
previous behaviour rather than pass vacuously. The board gains an aria-invalid
sample beside the Field.Root one.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add the @lody/ui choice controls

The field family covered the controls a person types into, while every control
a person picks with still rendered Radix with Tailwind classes the new token
rules define no equivalent for. checkbox.tsx and radio-group.tsx reached for
data-[state=checked]:bg-primary and switch.tsx for bg-switch-track, and primary
is exactly the concept the redesigned system does not have: the rules replace it
with an elevation rung plus ink. Their disabled treatments were 50% where the
rules say 45%, and their focus rings were ring-offset haloes the rules ban and
the desktop shell suppresses anyway. Migrating them one at a time across that
gap would have let each control invent its own checked colour.

Add Checkbox, Radio and Switch to the same field token group rather than opening
one of their own, and settle the state the text controls never had to answer: a
control that holds a value is ink, the label fill under a background mark with
shadow.inkEdge on top, because the rules reserve accent for live state and never
a fill. src/field/well.ts grows box, checked and checkedInvalid beside base and
invalid, so the family still defines its resting, ring and disabled appearance in
one file; the ring is restated for the ink edge because CSS cannot append to a
box-shadow list, which is the same shape Button already uses per variant.

Each renders a real <button> through Base UI's nativeButton rather than the
default <span>. A span does not match :disabled, so the family's one disabled
mechanism would have needed a second copy read from React state; a span is not a
labelable element, so a <label> click would not toggle it; and the repo's tests
already query button[role="switch"]. A disabled control leaves the tab order in
exchange, which is what a disabled <input> in the same family already does.

Radix modelled the mixed checkbox as checked="indeterminate", so every call site
typed the value as boolean | 'indeterminate' and narrowed it back. Base UI has a
separate indeterminate prop, and the two select-all sites now pass both. A mixed
box wears the checked ink with a dash and announces mixed rather than falling
back to a tick it does not hold.

All 33 callers moved onto the primitives and checkbox.tsx, switch.tsx and
radio-group.tsx are deleted with their barrel exports and their three @radix-ui
dependencies. radio-group.tsx had no caller and no barrel export, so Radio and
RadioGroup arrive with none either. Three callers were carrying the old
dimensions or rebuilding the control's appearance and lost those classes;
pointer-events-none and shrink-0 stay, because they are the caller's constraint.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: add PointerEvent polyfill and act import to Switch component tests

The new @lody/ui Switch component uses Base UI React's Switch, which depends
on PointerEvent in the dispatchClickWithModifiers handler. Tests interacting
with Switch components need a PointerEvent polyfill in jsdom.

- Add TestPointerEvent class to both test files
- Install PointerEvent polyfill in beforeEach
- Import act from react-dom/client for consistency with React 19
- Fixes CI test failures in mobile-about-developer-mode and tasks-beta-gate

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(ui): give pills the round corner shape, not the squircle

The switch track shipped as a rounded rectangle. `well.box` puts
`corner-shape: squircle` on every control in the family, and the track, the radio
and Button's pill then set `border-radius: radius.full` expecting a stadium or a
circle. A squircle does not degrade to one as the radius grows: it draws a
superellipse at that radius, so a 28x16 track becomes a rounded rectangle and a
16px box becomes a squircle rather than a circle. Measured in Chromium against
`corner-shape: round` at the same radius, the two are plainly different shapes.

The corner rule said `corner.shape` rides along with every radius, which was
written before anything used `radius.full`. Add `corner.round` beside
`corner.shape` and give `radius.full` the exception in RULES.md and AGENTS.md:
that radius means a pill or a circle, and only the round shape draws one. The
switch track and the radio box take it; the thumb and the dot never needed it,
because `corner-shape` does not inherit and their default is already round.

Button's pill carried the same defect from the start and is fixed with them. The
gallery's `radius.full` chip did too, so the board was drawing a rounded
rectangle under the label "pills"; it now shows the round shape beside the four
squircles, and the Corners rule states the exception.

Three tests pin it, deriving both expected classes from StyleX rather than
writing them down: the switch and the radio carry the round class and not the
squircle, the checkbox keeps the squircle at its 5px radius, and the two classes
differ at all.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: import act from react instead of react-dom/client

The act function should be imported from React, not from react-dom/client,
for compatibility with Vitest's jsdom environment. This fixes the
'(0, act) is not a function' error in component tests that use Switch.

- Import act directly from 'react' in both test files
- Fixes CI test failures in mobile-about-developer-mode and tasks-beta-gate
- All 11 tests now pass locally

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(components): prove the selection loop stops by its effect, not a frame count

`scrolls at the edge, selects newly reached messages, and stops on release`
counted outstanding requestAnimationFrame callbacks to show the auto-scroll loop
had stopped. That held only while the loop was the sole rAF user under the
component. The migrated Checkbox brings Base UI's transition-status bookkeeping
with it, and Base UI's scheduler keeps its last native frame queued on purpose,
turning it into a no-op rather than cancelling it, so a checked box always
leaves one frame outstanding and the count can never reach zero again. No
product code can change that; the assertion is what has to move.

Assert what the test's own name claims instead: after release, draining whatever
frames remain must not advance the scroll. That keeps its teeth -- neutering
`end()` makes the scroll run on from 8.87 to 26.6 and the test fails -- and it
no longer depends on which library happens to hold a frame.

Also drop the `React` default import 89f6583 added to
mobile-about-developer-mode, which that file never uses; tasks-beta-gate keeps
its own because it references `React.ReactNode`.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the note's claim that the local test failure was environmental

The note excused `packages/components`' suite failing with
`act is not a function` as pre-existing, on the evidence that the base commit
failed the same way. That evidence was worthless: React 19 exports `act` only
from its development build, so the suite needs `NODE_ENV=development` and fails
on both sides without it. Dismissing it hid the three failures that then reached
CI. Record what actually happened, including why an outstanding-frame count
cannot survive a Base UI control in the tree.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Code <noreply@github.com>
* feat(ui): add the @lody/ui Select and Combobox primitives

The field family covered the well rung; Select and Combobox are the first
controls in it to open a floating list, so they need a second vocabulary. The
trigger stays in the `field` group — same size ladder, ring, invalid ring and
disabled opacity as an Input — while the list takes a new `popup` group on the
floating rung, shared with the menus that follow rather than borrowed from a
control it does not resemble.

`popup/surface.ts` holds the appearance both lists share, the way `field/well.ts`
holds the one the controls share, so a row cannot be styled twice. Select.Content
and Combobox.Content assemble Base UI's portal, positioner, popup, list and
scroll arrows once, and a `PopupContainerProvider` lets a host that owns a focus
scope name where a popup mounts.

Model: claude-opus-5[1m]

* feat(ui): migrate every Select caller onto @lody/ui

All twelve callers move to `Select.Root`/`Trigger`/`Value`/`Content`/`Item`, and
`packages/components/src/ui/select.tsx` is deleted with its Radix dependency.
The classes that rebuilt the deleted control's density — `h-9`, `h-8`, `text-xs`,
`w-full` — are gone; the size ladder is the primitive's prop.

Three differences from Radix cost real work rather than an import swap:

- `Select.Value` resolves the label of the current value from `Select.Root
  items`, not from the rows, so every caller whose row text differs from its
  value now states its list once and renders both from it.
- `null` is Base UI's empty value, so a handler for a control that cannot be
  cleared narrows it rather than casting.
- A Base UI popup inside a Radix dialog has to mount in the dialog panel, which
  owns the focus scope and the scroll lock. `DialogContent` names itself as the
  popup container once, and the list anchors under its trigger rather than
  overlapping it, which also keeps it off the fixed-position path a `translate`d
  panel reinterprets.

Model: claude-opus-5[1m]

* docs(ui): record the popup token group and the Select/Combobox slice

The token rules gain a "Popups and lists" section: why a trigger reads `field`
while the list it opens reads `popup`, how a row's radius derives from the
popup's inset, and why both row fills are mixed from the floating rung instead
of taken from `hoverFill` and `selectedFill` — measured, each of those resolves
to invisible on that rung in one of the two palettes.

The spec states the intent and stays `draft`. The note records the three defects
the browser found that no test did, and what driving a Base UI popup in jsdom
actually needs.

Model: claude-opus-5[1m]

* fix(ui): keep a popup in the palette, the row edge and the empty row honest

Four defects found by pulling the branch down and clicking the controls, all the
same class as the three the board already caught: a popup is not where it was
written, so anything that depends on where it is breaks quietly.

- `Combobox.Empty` stays mounted so a screen reader has a live region to
  announce into, and it carried a 28px min-height whether or not it held a
  message, so every popup opened with a blank row. It collapses through `:empty`
  — rendered and in the accessibility tree at zero height, which `display: none`
  or `aria-hidden` would not be.
- A portalled popup left the subtree that declares its palette and inherited the
  document's, so a dark panel opened a light list. `ThemeRoot` publishes its
  mode and `Content` re-declares the palette on the positioner.
- The shell rings any focused `[tabindex]` through a zero-specificity `:where()`
  rule, and Base UI moves focus onto the highlighted row, so a stray accent
  border followed the keyboard down the list. A row states `box-shadow: none`:
  it owns its edge, which is no edge.
- With the palette fixed the board's stand-in and the real list are identical,
  so the board now says which is which rather than leaving a reader to guess.

Model: claude-opus-5[1m]
@lody/ui already owned the floating surface a Select and a Combobox open, but
every menu in the product was still Radix with Tailwind classes reaching for a
vocabulary the token rules do not have. menu-styles.ts computed the menu's edge
as a color-mix against --background and painted it as a hairline ring, which is a
workaround for a border token in a system whose rules say edges are wells, raised
shadows, elevation shadows and the focus ring. Rows were min-h-8 at rounded-lg
inside a rounded-xl surface, neither pair on the nested-radius rule; group labels
were 10px at tracking-[0.6px]; disabled was 50% where the rules say 45%. Three
files carried that vocabulary in three copies, and menubar.tsx, the fourth, was
shadcn's default and had no caller at all.

Add the family as one component rather than four. A menu is the surface a list
already opens: it reads the popup group and src/popup/surface.ts, and replaces
exactly one declaration. A list states min-width: var(--anchor-width) because the
control it belongs to shows the value the list holds; a menu is opened by whatever
the surface already had there, often a 28px icon button, so it states
popup.menuWidth. Base UI has no separate dropdown part, so Menu is the dropdown
menu; ContextMenu restates only its root and trigger and Menubar only the bar and
its names, and both re-export Menu's rows. Three tokens join popup rather than a
group being opened beside it: menuWidth, destructive and destructiveHighlight,
the last mixed toward destructive for the same reason highlight is mixed toward
label, because the palette's named fills collapse into this rung.

The rise is restated as a distance from the anchor rather than from the page: a
menu flips to stay on screen and a submenu opens beside its row, so the popup
starts one step further from what opened it on whichever side it landed. A context
menu is anchored to the pointer, so ContextMenu.Content states no offset at all.
A row's leading box holds one thing: a checkbox or radio row's mark is unmounted
while the row is unticked, so a caller's glyph sharing that box would slide
sideways every time the row was toggled. Post-close focus stays the product's, as
Base UI's finalFocus; Radix's touch pointerdown workaround is dropped, because
Base UI already tells a tap from a scroll.

Three defects survived a green suite and were caught in Chromium, all the same
shape: the board renders to static markup and a menu is portalled and unmounted
while closed. A bare Menu.GroupLabel throws, which is the migration's sharpest
edge since Radix's label rendered anywhere and there are 52 of them;
popup.menuWidth read back as 0px because the stand-in reused the list's style;
and two JSX attribute strings rendered as their own escape sequences.

The 41 dropdown-menu and 11 context-menu callers are NOT migrated here. Radix's
menus have about a thousand usage sites and this is not an import swap: onSelect
with preventDefault becomes closeOnClick, asChild becomes render, align and side
move to the positioner, and DropdownMenuSearchInput exists only to fight Radix's
typeahead. Menubar is the one piece that is complete, having had no caller, so
its Radix file, barrel export, @radix-ui/react-menubar dependency and attribution
entry are gone.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#634)

* feat(ui): add the @lody/ui Popover, Dialog family and Tooltip

@lody/ui owned the floating surface a Select and a Menu open, but everything
else that sits over a page was still Radix with Tailwind classes reaching for a
vocabulary the token rules do not have. popover.tsx and tooltip.tsx carried
border border-border in a system whose first rule is that no border token
exists; dialog.tsx and alert-dialog.tsx disagreed about their own animation,
one having been changed to drop the zoom the other still had; sheet.tsx was a
cva over four sides. dialog.tsx also held the one piece of real design in the
set, naming its own panel to PopupContainerProvider, which belongs in the
primitive rather than in a product file.

Add Popover, Dialog, AlertDialog, Sheet and Tooltip. A popover is not a new
surface: it reads the popup group and src/popup/surface.ts and replaces five of
a list's declarations, the --anchor-width a list takes because its control shows
the value it holds, the 4px inset that lets a row bleed to the edge, and the
three that make type a control's rather than prose. Two tokens join popup rather
than a group being opened beside it, panelPadding and panelGap, plus description,
which resolves to the same colour as groupLabel today and means something else.
test/popover.test.tsx pins the five as a count the way the menu pins its one.

Dialog, AlertDialog and Sheet are one family under a new dialog group sharing
src/dialog/surface.ts, for the reason field serves the whole control family:
they differ in how they arrive and in what may dismiss them, not in what they
are made of. Each names its own panel as the container every Select, Combobox
and Menu inside it mounts into. An alert dialog's answers stay Buttons rather
than becoming parts, because which variant an answer takes is the surface's
decision; that drops the old AlertDialogAction/Cancel pair, which decided both.
Safe areas are read with env() rather than a host-published variable, so the
package stays platform-neutral.

Tooltip is the one floating part that does not read popup. The ladder puts a
menu, a popover and a list on raisedBackground under shadow.popover and then
names the tooltip apart, label with shadow.medium: an inversion, because a popup
is a place to act and a tooltip only names one. It never takes the pointer and
sits above every popup, because what it names may itself be in one.

Two defects the tests caught: the panel container was a ref, and React attaches
a child's refs before its parent's, so a Select opening in the same commit as
its dialog read null and portalled to the body, the one case the mechanism
exists to prevent; and Escape does close a Base UI alert dialog, which the first
draft claimed it did not. Three more were caught only in Chromium. Every sheet
collapsed to its content height at the bottom of the window, because StyleX
keeps one class per property key and does not know top, inset-block-start and
inset-block name one property, so both survived and the cascade picked top:
every inset in dialog/surface.ts is a logical longhand now and each edge states
all four. The board's dimension probes reported their flex-shrunk width rather
than the token, and its dialog stand-in ran off its column twice.

Sheet is migrated: all five callers move and packages/components/src/ui/sheet.tsx
is deleted with its barrel export. Popover, Dialog, AlertDialog and Tooltip are
NOT, and the reason differs. The deleted PopoverContent folded useSafeAreaInsets
into collisionPadding on every popover, a product policy @lody/ui cannot hold,
and two call sites use PopoverAnchor. Dialog, AlertDialog and Tooltip are about a
thousand usage sites, and Base UI 1.7 makes a tooltip visual-only with no role
and no aria-describedby, so a control whose only accessible name came from its
tooltip goes nameless after a mechanical port; each of those 75 files has to be
read rather than swapped.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ui): replace Sheet with Drawer, built on Base UI's own

The first version of this branch shipped a Sheet built on Base UI's Dialog with
a side prop. That is Base UI's Drawer reimplemented out of the wrong primitive:
its own subtitle for Drawer, "a panel that slides in from the edge of the
screen", is word for word what that Sheet's documentation said. It is the mistake
the menu note already recorded under another name — a second name for the same
component and a second thing to keep in step.

The behaviour is the real argument. A panel that slides in from an edge promises
that it can be sent back, and on a touch screen a person will try; a dialog
cannot answer that gesture at all. The repository had already voted: 23 files
reach for vaul precisely because the Dialog-based sheet was not enough, and every
one of the five callers this branch migrates is a mobile bottom or side panel, so
Sheet would have made five surfaces worse on touch than the vaul drawers beside
them. Naming the primitive Drawer also resolves rather than deepens the collision
with the eleven vaul files already named *-sheet.tsx.

A drawer is laid out by a viewport rather than positioning itself, which is the
mechanical reason the two cannot be one component: a dialog centres itself with
translate(-50%, -50%) and spends the one property CSS has for movement, while
Base UI puts the panel in a fixed Viewport whose alignment picks the edge and
leaves the panel's transform free to carry --drawer-swipe-movement-x/y.
test/drawer.test.tsx pins that the drawer panel never carries the dialog's
centring class.

Two axes. The edge is stated in writing direction and the physical swipe is
derived from it, flipping only the inline pair, so a drawer on the start edge
departs leftwards in a left-to-right document and rightwards in a right-to-left
one; the direction is read from the document rather than taken as a prop. Flush
or inset is the second: flush meets the window, squares the two corners that
touch it and pads its own content clear of the safe area, while inset floats at
dialog.drawerInset with all four corners and takes the safe area as viewport
padding. The gap is the viewport's padding rather than the panel's margin for the
same reason the position is — a margin would be one more thing competing with the
drag.

The scale gains duration.slow at 320ms, because a drawer crosses the window and
180ms over 600px reads as a snap rather than a slide. The backdrop lifts with
--drawer-swipe-progress rather than only at the end, so a half-dismissed drawer
shows a half-lit page and the gesture reads as reversible.

The modal rung is otherwise unchanged: Drawer reads the same dialog token group
and the same dialog/surface.ts as Dialog and AlertDialog, so the appearance work
already on this branch carries over. sheetSize becomes drawerSize. All five
callers move from Sheet.* to Drawer.*, with session-detail.tsx aliasing the new
primitive as UiDrawer while the vaul Drawer there still holds the bare name.

Verified in Chromium: all eight configurations measured against the viewport —
flush and inset on each of the four edges — with the derived data-swipe-direction
read off each. A drag cannot be synthesised from JavaScript, since Base UI reads
real pointer input, so what was checked instead is this package's half: setting
--drawer-swipe-movement-y moves the panel by exactly that much and
--drawer-swipe-progress fades the backdrop to match.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): stack the drawer viewport, not the panel inside it

The overlay painted over the drawer: the panel rendered greyed under its own
backdrop, which looks subtly wrong rather than obviously broken.

`position: fixed` creates a stacking context, so the `z-index: z.dialog` on the
panel ordered it only against its own siblings inside the viewport. The viewport
itself declared no `z-index`, counted as `auto` against the page, and the
backdrop at `z.dialogBackdrop` won. The rung's stacking belongs on the viewport,
because that is the element stacking against the product shell; the panel inside
it now states none rather than claiming something it cannot honour.

Unlike the other defects on this branch, this one was checkable without a
browser — it is a fact about which classes compile onto which part.
test/drawer.test.tsx pins it: the viewport carries the same z-index class the
dialog panel does, the backdrop carries the other, and the drawer panel carries
neither. Reverting the fix fails that test.

Verified in Chromium with elementFromPoint over the panel for all six modal
surfaces — flush and inset drawers on each edge, the dialog and the alert dialog
— where the topmost painted element is now inside the panel in every case.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four files were assembling the same segmented control out of Tailwind and
disagreeing while they did it: tabs.tsx gave a list h-9 bg-muted p-1 with
data-[state=active]:bg-background on the trigger, context-switch.tsx restated it
at h-10 with a border and a second version for a dark tone, chat-landing.tsx
restated it again with a different shadow, and mobile-stats-settings.tsx used
grid-cols-4 and no height at all. Two carried a border in a system whose first
rule is that no border token exists, and the four heights included one that is
not on the control ladder. accordion.tsx was shadcn's default with border-b and
two keyframes that exist nowhere in the token rules; collapsible.tsx was three
re-exports of Radix with no design in it, so two of its three callers wrote no
reveal at all.

Add Tabs, Accordion and Collapsible as one family under a new disclosure token
group. They are one family for the reason dialog covers three modals: each is a
trigger and the thing it shows, and what differs is the arrangement. A strip
lays the choices side by side and swaps the panel under them, an accordion
stacks them and opens one in place, a collapsible is one of those rows with no
list around it. The group is deliberately not field: none of the three holds a
value, so none takes a name, a validity or a field around it.

The strip is the elevation ladder read twice over, a well-rung track with one
thing raised out of it, which is the pair a Switch takes and the pair the Edges
rule already gives "you can press this". The indicator is one element that
slides rather than a fill on each tab, because the strip is a single control,
and Tabs.List draws it rather than the caller for the reason a submenu's chevron
is drawn by its row. It is anchored physically and moved with translate: Base UI
publishes the selected tab's box as pixels from the list's top-left corner, so
reading them from the inline edge would put the pill on the wrong tab in a
right-to-left document. The size is stated once on the strip and the tabs follow
it, including the nested radius, and stretch is one prop because it is two facts
that have to agree.

An accordion row has no fill in any state, so the line to the next row is the
separator the rules give a list, drawn as an inset shadow because an accordion's
rows are its own children and there is nowhere for a caller to put a part. A
revealed panel's padding rides on a child of the panel: Base UI animates the
height from a size it measures with scrollHeight, which counts padding, so a
padded panel is cropped by exactly its own padding under border-box and
overshoots by it under content-box. A Collapsible's trigger stays Base UI's,
unstyled, because a lone disclosure is opened by whatever the surface already
had there.

One defect survived a green suite and was found by opening the board: a disabled
tab was not dimmed. Base UI puts no native disabled attribute on a tab or an
accordion row — both stay focusable so a keyboard reaches them and hears
aria-disabled — so :disabled never matched. Both now take the dim from Base UI's
state, and the tab freezes its hover colour with it.

Migrate seven surfaces and delete the Radix accordion.tsx and collapsible.tsx
with their barrel exports. ContextSwitch stays: its tone prop paints a second
strip for the landing page's dark hero, which wants that hero to declare a
palette with ThemeRoot rather than a rename, and its unavailable tab must still
answer a click.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add the @lody/ui feedback family

Five files, five vocabularies, three of them reaching outside the token rules.
alert.tsx was a cva with rounded-lg border px-4 py-3 — a border in a system
whose first rule is that no border token exists — whose destructive variant
coloured the whole message red and left its surface the card. progress.tsx was
bg-primary/20 holding a bg-primary child, which no rule here names: accent is
for live state, and an alpha of the primary colour is neither. skeleton.tsx was
animate-pulse bg-primary/10, where the rules say a skeleton is one of the few
things that takes a gray. loading.tsx welded two decisions together, the mark
and where it goes. The product had also grown a status palette in Tailwind that
@lody/ui could not see, so a warning in a settings page and a warning in a
primitive were two different oranges by construction.

Add Alert, Toast, Progress, Skeleton and Spinner as one feedback family, and
two semantic colours — success and warning — whose values are the product's own
--status-success and --status-warning in both palettes. They name an outcome
rather than an action, so destructive stays the colour of a thing that destroys
and of a thing that failed, and there is no third red.

An Alert and a Toast are one message on two rungs: an alert stays on the page it
is about and takes the card rung, a toast arrives over that page and takes the
floating one. Neither is modal, because a message does not have to be answered.
A tone is a tint and a mark and never a fill: the tint is 8% of the tone mixed
into the rung's own background, the mix a destructive menu row already uses, and
it is mixed in feedback/surface.ts rather than frozen into tokens because it is
a mix of a surface and the two surfaces are on different rungs. The mark belongs
to the tone rather than to the caller — a caller free to choose a glyph can put
a tick on a failure — and so does the role: alert for a failure or a warning,
which interrupts a screen reader, status for the rest, which waits.

Toast.Provider renders its own viewport, so a surface wraps its app once rather
than keeping a provider, a portal, a viewport and a list in step, and the tone
travels as Base UI's type from the call that reported it.

A bar is accent, because the rules name the running indicator; a bar with no
value is not a bar at zero but the same track with a band crossing it. It takes
a tone too, for a quota nearly spent and for a meter that is not live at all. A
skeleton takes a gray and takes its room as props, because a size passed as a
class lands in a specificity fight with the shape's own height. A Spinner is the
mark rather than the state, drawn in currentColor so the one inside a ghost
button takes the button's ink, and it keeps turning under reduced motion because
a still spinner says the work has stopped.

Two defects found by opening the board rather than by the tests. A neutral alert
was invisible: in the light palette the card rung and the page are the same
white, so it now takes a 4% tint of label, the derivation a popup's highlight
already uses. And the indeterminate band swept to 350% of its own width, leaving
the bar empty for a quarter of every cycle; it ends at 250%, where its leading
edge reaches the end of the track. A third was structural: a toast's arrival was
a keyframe animation on the starting status, which lasts one frame and cancels
the animation — it is a transition between two declared ends now.

Migrate seventeen surfaces and delete alert.tsx, progress.tsx, skeleton.tsx and
loading.tsx. The toast is built but migrated nowhere: 61 files and 267 call
sites reach for sonner, and two toasters alive at once would stack messages in
two places, so that flip is one change of its own.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): give an icon-only Button the box its glyph needs

This package's glyphs state their size as 100% of whatever holds them, and every
holder gives them a 16px box: a menu row's leading box, a Select's chevron, a
message's mark. An icon-only Button gave none — it sets only a square width and
no inline padding — and StyleX has no descendant selector with which a button
could reach the glyph inside it. So a cross filled its button edge to edge:
measured in Chromium, the Dialog's close and the Toast's close were both 28x28
glyphs in 28x28 buttons, where a Select's chevron beside them was 16.

Add button.iconSize and render that box around an icon button's children. The
122 icon buttons in @lody/components size their own lucide icons at 14 or 16px,
so the box changes nothing for them and constrains the three that pass a
caller's icon straight through. The board's own PlusGlyph had been working
around the same hole with a hardcoded width="14", which is gone now.

The defect predates this branch — it has been in the Dialog's close since the
overlay family landed — and it is fixed here because the Toast's close is the
second part to inherit it.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add the @lody/ui Table and Pagination

Rows of records, and the way to the ones that did not fit. One `table`
token group covers both, the way `dialog` covers the Drawer: a pager
exists because a table did not fit, the two sit on the same rung and
state the same size.

A table is the only part of this package with no surface of its own —
no background, no shadow, no radius — so it is rows on whatever the
surface around it already was, and its single edge is the `separator`
between one row and the next. The head takes that line because the row
after it is the first record; the last record draws none. That edge is a
border rather than the inset shadow every other row here uses, because
under `border-collapse: collapse` a row's box-shadow is not painted and
under `separate` its border is ignored.

Hover is opt in through `interactive`: the Radix table this replaces lit
every row, and its one caller had to turn that off again with a class.
The two fills are the palette's own `hoverFill` and `selectedFill`,
which the rules name for a row on the page rung; `selected` is a fill
and nothing else, since `aria-selected` belongs to a row in a grid.

`Pagination` is one control rather than a kit of parts: the window is
one width from the first page to the last and admits a gap only where it
hides more than one page. `compact` says where you are when there are
too many pages to list, and `jump` commits on Enter or on blur rather
than on every keystroke.

Base UI ships neither component, so both are written the way Skeleton
and Spinner are; Base UI enters through the Button and Input the pager
is built from. `packages/components/src/ui/{table,pagination}.tsx` are
deleted and their callers migrated — the onboarding summary and the
paged file viewer, which loses its keystroke navigation.

Model: claude-opus-5[1m]

* refactor(ui): make Table a column-driven primitive

The first version of this branch was a styled `<table>` — parts mirroring
the HTML elements, with the design work all in the skin. It was reviewed
as conventional, and this replaces it.

A column is stated once. `Table` takes `columns: TableColumn<Row>[]`, and
from that one fact it owns what a caller assembling elements has to get
right every time: the widths (through a `<colgroup>` that reaches cells
the caller never writes), the ordering (one column at a time, on the root,
so two columns wearing the arrow cannot be expressed), the selection (a
`Checkbox` in the row with the head's box derived — none, mixed, all — and
keys off screen surviving a select-all), the empty row's span, the head
that stays, and the stack a table becomes when its own container is too
narrow. The evidence is in this repository: both surfaces that draw a
table write their `grid-cols-[…]` template in the header and again in the
row, and one renders every cell a second time for a narrow window.

`interactive` becomes `onRowPress`, which brings the hover with the thing
that justifies it and takes the keyboard along: Enter and Space press a
row, and it rings where the keyboard is.

The border exception is deleted. A six-case Chromium probe showed the
earlier claim was wrong — the inset shadow had been hidden by an opaque
cell background, not by the table model. Under `border-collapse: separate`
a row's line, a row's focus ring and a sticky head's line all work, while
under `collapse` a sticky head loses its border, so the table is back on
the `inset 0 -1px 0` every other row in this package draws.

Narrow is the table's own width, not the window's: StyleX supports
`@container`, so a table in a 360px side panel stacks on a 27" screen.
Without it the primitive could migrate nothing — all three real tables
here collapse by hand today.

`review-policy-setting.tsx`, which hand-rolled role="table" around a
template written twice, is migrated; `parts.tsx` keeps the elements for a
table that is not a list of records, which is what the onboarding summary
is. The board caught a defect the tests could not: a table that renders
its own rows passes `last`, so `:last-child` stopped applying and no body
row drew a line at all.

Model: claude-opus-5[1m]
Three files, three vocabularies, each reaching outside the token rules in a
different direction. card.tsx was rounded-lg border border-border shadow-xs —
a border in a system whose first rule is that no border token exists — with p-6
stated once per section, and a text-2xl title that is not a step on this type
scale. badge.tsx was a cva whose default variant filled the chip with ink, which
the rules give a stored value, plus a hover and a focus ring: a badge that looked
like a primary Button and invited a press it does not answer. Of its 44 call
sites 38 overrode the size, and the onboarding screen had grown a
PROVIDER_STATUS_CHIP constant and four hand-mixed colour pairs for statuses the
palette now names. separator.tsx defaulted to decorative, which is role="none",
in a system where the one place a line is allowed is structural by definition.

Add Card, Badge and Separator as three components rather than a family: they
share no colour, measurement or state, only the question every part here answers
— what its edge is. A card's edge is its shadow, a badge has none because it is
on no rung, and a separator is the one edge the rules allow.

A Card is a Dialog's panel one rung down and takes the same parts, because what
separates a panel that owns the window from a block that owns a region of a page
is the rung and the heading step rather than what either is made of. It keeps its
own token group for the reason a menu does not read field.background. Its title
is headline: the rules reserve title for a page that is a page, so the eight
migrated pages drop their off-scale 24px headings. A card does not nest — two of
them are one fill twice in the light palette — and renders no control of its own:
interactive marks the card and the caller still brings the button, because what a
press does is a product decision.

A Badge is on no rung: it appears on a page, a card, a menu row and a modal
panel, three of which the rules already record as collapsing the ladder's named
fills. Its tone is therefore a film of that tone over whatever is underneath, the
form a destructive ghost button's hover already takes. Its words stay ink in
every tone, and that is measured: light-palette warning is 2.8:1 on a near-white
surface, a colour tuned for a 16px mark where the bar is 3:1, and a badge is
never wordless — the tint carries the tone and the word carries the fact. Five
tones, the fifth being running, which Progress adds for the same reason. No
hover, no focus ring, no filled variant; a chip a person can press is a Button.

A Separator takes no token group, because it is the edge rather than deriving one
— a separator.color would be a second name for one fact. It is announced rather
than decorative, carries no margin of its own, and stretches to its row when
vertical instead of taking a percentage of a height the row has not got.

One defect the board caught rather than the tests: card.hover cannot be the
region rung the elevation table named for it, because in the dark palette
secondaryBackground and elevatedBackground are the same value and the hover
would have been invisible in exactly one palette. It mixes the rung toward label
at 4%, the derivation a popup's highlight and a secondary button's hover already
use, and the ladder's table is corrected to match.

Migrate twenty-nine surfaces and delete badge.tsx and separator.tsx with the
orphaned @radix-ui/react-separator dependency. billing-setting-pure.tsx keeps
the old Card: its table cards need a flush surface this system does not have
yet, and its tinted callouts are Alerts, which is a change that can be read
against that file.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Table sample on the gallery board rendered as the stacked list, including
the one sample whose entire point is that it is the exception. The section read
as a wall of label-and-value pairs rather than as a table, and the primitive was
not at fault: the board handed each sample a 342px box, under the 480px at which
the rules say a table stops being columns.

Two board defects. The section used the two-palette split — minmax(340px, 1fr)
twice across a 1120px page — and the 168px legend column beside it left 342px.
PaletteSplit now takes wide, one palette per row, and the Table section asks for
it; samples measure 908px and the sample with a 320px box is the only one that
stacks. And a sample's note shared the wrapping row with the sample and competed
for width, so two tables of the same size were laid out 260px and 342px wide on
one board. Notes are a Note part at flex-basis: 100%, which always takes its own
line, and carry no max-width: a flex item's base size is clamped by max-width
before the line is broken, so a 72ch note fits beside the sample after all and
squeezes it to its minimum — the same bug wearing a reading measure.

The 480px stack width is unchanged. It is conservative for three columns whose
stated widths total about 300px, but it is the system's number and leaves room
for the values rather than only the column names.

Nothing in this package's tests can see any of this — jsdom applies none of
StyleX's CSS — so it is read off the rendered page, which is why parts.tsx now
carries the rationale for both splitWide and Note beside them. Verified in
Chromium under both palettes: every sample 908px with real columns, the narrow
one 320px and stacked.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

* feat(ui): add the @lody/ui Avatar and Kbd

@lody/ui had every part that acts, reports or opens, and nothing for the two
things an interface has to name but does not own: a person, and a key on their
keyboard. avatar.tsx was a Radix wrapper with one size, size-8, and of its
twenty-seven call sites twenty-one named a box and fifteen also named a type
step for the letters — h-5 w-5 text-[9px], h-7 w-7 text-[11px], h-16 w-16
text-xl — two facts every surface had to keep in step, at eight different sizes
with no decision behind the gaps. Four of them also restated the fill, the last
putting accent — which the rules reserve for live state — under a static
portrait. kbd.tsx reached the surface it sat on with a descendant selector
StyleX cannot express.

Add Avatar and Kbd as two components with two token groups and one question:
neither stands for anything in this interface, so both take the gray ramp, which
is what the rules already reserve for a thing with no role. Skeleton was there
first; the rule's list grows by one rather than the palette growing.

The rung picks the letters. Five rungs — 16, 20, 24, 32, 64 — each carrying its
own type step and its own glyph box, so a caller states one fact instead of two
that can disagree. The ladder is deliberately not the control ladder's 28/32/36:
an avatar in a row is a mark beside text rather than a control, and the 28 and 36
the product had reached for were a row avatar that had drifted. A person is a
circle at radius.full on corner.round; a thing is a tile, because a circle around
a logo is a crop and the mark inside one was drawn square, and its corner is the
radius-by-size table read per rung. The fill is a gray rather than a badge's film:
what an avatar stands in for is a photograph, and a translucent face would show a
sidebar row's hover through it. The letters take label rather than secondaryLabel,
which is measured — on this gray the secondary label is 3.5:1 in the light
palette, under the 4.5:1 that 8-to-11px letters need. An identity colour stays the
product's and arrives as a style, so Avatar.Fallback is the one part here that
merges a caller's style rather than replacing it.

A Kbd is a key cap, and a KbdGroup the chord it is pressed in — a kbd around
kbds, which is the shape HTML gives one gesture. It is never a control and never
a menu row's shortcut: the rules give that slot plain trailing metadata, because
a column of chips down a menu's right edge turns a quiet list into a keyboard
diagram. The deleted descendant selector is replaced by the mechanism this
package already trusts for a portalled palette: Tooltip.Content declares
kbdOnInvertedTheme on its popup and every cap under it inherits, which unlike a
[data-slot] selector also reaches a cap a caller wrapped in something of its own.

Two defects the board caught rather than the tests, because jsdom applies none of
StyleX's CSS. A 16px circle holding two initials laid out 20px wide and stopped
being a circle: a flex item's automatic minimum size is its content's, and width
loses to it, so the box and its fallback both give it up — an avatar is a ceiling
as well as a floor. And WW at the first draft's 8px step measured exactly 16px
against that circle, losing its outer strokes to the crop; the step is 7px, which
measures 14px.

Migrate twenty-seven avatar call sites and four key-cap files, and delete
avatar.tsx and kbd.tsx. fallbackClassName goes with them, since everything it
carried is the package's decision now. One Radix tooltip moves too: the sidebar's
new-task button is the only surface that puts a Kbd inside one, and only
@lody/ui's Content declares the inversion a cap there needs to stay readable.

The hashed identity hue is still written twice, here and in
MobileInitialLetterAvatar; moving it into the package would make the package own
a brand colour, which is a product decision rather than a migration.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: link the UI avatar and kbd note to its PR

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): make a badge's five tones tell each other apart

The tones were reported as hard to distinguish in use, and measuring them
against each other rather than each on its own showed why. Off the rendered
board, the closest pair of composited chips was 0.019 apart in oklab in the
light palette (neutral against success) and 0.026 in the dark one (running
against warning) — a difference that survives a swatch comparison and does not
survive a 20px chip glanced at in a row.

Raise the films from 8% of label and 14% of a tone to 12% and 22%, which puts
those closest pairs at 0.030 and 0.035. It is still a film: the word is what
says which tone a badge is, and the tint only has to make that believable at a
glance — but a film nobody can separate is a film doing nothing.

And the ink was already failing. Raising a tint costs contrast, so the word was
measured on its own chip, which the original change never did: secondaryLabel on
a danger badge was 3.9:1 on the page and 3.4:1 on one inside a popup, against
the 4.5:1 that 11px text needs. Those are the old numbers — the film's strength
made a failing number worse rather than causing it. badge.label is colors.label
now, which clears the bar in every tone on every rung in both palettes, by 9:1
at the narrowest, measured off Chromium's own compositing rather than modelled.
A badge stays quieter than the thing it is attached to by taking the caption
step, which is a size, not by taking a colour that cannot carry its own words.

What the earlier reasoning got right and keeps: do not colour the words with the
tone. warning at 11px is still 2.8:1.

running against warning in the dark palette stays the closest pair at any film
strength, and raising it only scales the gap: dark accent is hsl(27 100% 80%)
and dark warning is hsl(43 96% 56%), sixteen degrees apart, so a wash of either
over near-black lands in the same warm brown. Separating them means moving a
semantic colour every focus ring, link and live indicator reads, which is not a
decision a badge gets to make. Recorded rather than fixed.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): let a badge's word carry its tone, because the film cannot

The stronger film was still reported as indistinguishable in the dark palette,
and chasing it with a bigger percentage was the wrong instrument. ΔE said the
dark palette was better separated than the light one at every strength — 0.035
against 0.030 at the shipped film — and hid which pairs are close. In light the
closest pair is neutral against success, a gray against a green, named apart
whatever the distance says. In dark it is running against warning, where accent
is hsl(27 100% 80%), a pale peach, and warning is hsl(43 96% 56%). Composited at
22% over near-black those are hue 61° at chroma 0.024 and hue 87° at chroma
0.053: two brown washes, twenty-six degrees apart, one of them barely chromatic.
Raising the film scales both and separates neither. Every candidate to 60% was
checked and the pair stays closest at all of them.

So give the word the tone. The rule this replaces — do not colour a badge's
words with its tone — was right about the raw tone and wrong to stop there:
warning at 11px is still 2.8:1, and that measurement is untouched. A tone pulled
halfway to label keeps its hue and gains the ink's contrast, and the worst of
the four then measures 5.2:1 on its own chip, on every rung, in both palettes.
label was already the ink, so one expression serves both palettes — it darkens
toward near-black in one and lightens toward white in the other — rather than a
palette-conditional value the component token groups have no way to carry.

Half is measured too. At 60% of the tone the worst word is 4.2:1, under the bar
on the floating rung where the chip is already a step darker; at 40% the hues
wash out.

This is the move the film could not make. A 20px chip's tint is a wash; its word
is the mark a person looks at. Measured off the rendered board, the four words
land 0.097 apart in oklab at the closest in the light palette and 0.048 in the
dark, against 0.030 and 0.035 for the films under them — and in dark, running
and warning become a peach and a gold at four times the chroma of their washes.

running and warning are still the closest pair in dark. They are now a peach
word on a brown chip against a gold word on an olive one, told apart at a
glance, rather than two brown chips which were not. Moving them properly apart
means moving accent, which every focus ring, link and live indicator reads.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): give the board's rungs something else to stand on

A card on the board looked like it had no background, and the report was "did
you change something about background? the contrast is terrible". Nothing had
changed. PaletteSplit's panel is elevatedBackground under shadow.card — it is
the card rung — so a Card sample was being shown on itself. The light palette
hid it, because there the card rung and the page are both white and the shadow
still separates them; in the dark palette elevatedBackground is rgb(22,22,22)
and so is the panel, leaving a card with no fill step and only a shadow already
spent on the panel behind it.

It reached further than the Card. The elevation ladder stood on
secondaryBackground, which in the dark palette is rgb(22,22,22) as well — the
same value as elevatedBackground — so a ladder of surfaces could not show its
own card or modal step. An Alert takes the card rung by that ladder's own table,
so its four tones were four tints of the panel under them. And the modal
stand-in painted two of the three things its own caption says the rung states,
on a surface that cancelled the first.

Stand each of them on a different rung. The ladder stands on the page, where
exactly one chip matches its ground — page — and that one is supposed to: a card
that cannot be told from what it sits on is a lie, a page that cannot be is the
truth. The Card and Alert samples get a page-rung block. The modal stand-in gets
the page under colors.overlay, painted as a background-image because
dialogReplica needs that box to stay the containing block for its close button.

The rule this leaves, now in AGENTS.md: a sample that is a rung needs a
different rung under it. The badge section already did this with three explicit
strips and it was read as a badge-specific flourish rather than the general
case. No test sees any of it — jsdom applies none of StyleX's CSS — so it is
read off the board, which is where all three of these were found.

Two things left alone and recorded in the note: the card-to-page step in the
dark palette is six values of 255, which reads because the shadow carries most
of the work and moving it means moving elevatedBackground; and the board's own
panel still sits on the board's page fill with only its shadow between them,
which is the relationship a card has to a page and is what the panel is.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ui): wait for the list to take the keyboard, not for two frames

select.test.tsx's "End walks to the last row" failed on CI and passed on the
commit before it, on a change that touched only the gallery and two documents.
It is not a regression and not the component. dom.tsx settles every interaction
with a fixed two animation frames, which is enough on an idle machine and is
otherwise the scheduler luck the root testing rule forbids by name. Base UI
schedules part of a popup's state on its own animation frame, so how many frames
an interaction needs is a property of the machine that ran it: cutting the
budget to one frame fails a different test in the same file than two does, which
is how the fragility was measured rather than guessed.

Every keyboard test in that file presses a key straight after opening the list,
and the failing one was the only one that did not first state the precondition
the others assert. A probe showed why that matters: moving focus off a row
clears every data-highlighted mark, so a key arriving a beat early finds a list
with no active row and does nothing — which is the false CI reported.

dom.tsx gains until(ready, what): a bounded wait on an explicit condition that
throws with what it was waiting for, so a real regression still fails the run
and says why. The test states its precondition and both of its effects through
it.

Verified with the frame budget cut to one, which is the stress proxy: before,
the select file failed there; after, all 244 pass. At zero frames the menu suite
fails wholesale, which is not load but a popup never opening — the proxy has a
floor, and one frame is it.

Model: claude-opus-5[1m]

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Member Author

Board check (static Storybook, Chromium via Playwright), light and dark palettes:

  • The Icons section renders as intended: every icon in a 24px box, the four sizes, the four treatments, accent colour arriving through currentColor.
  • Glyph masks are unique and carry the icon name (lody-icon-copy-_r_jm_), so the cut-outs are transparent on both palettes.
  • --lody-icon-t is registered (CSS.registerProperty reports it already registered), the two sidebar toggles resolve it to 0 / 1, and the divider computes to matrix(1,0,0,1,0,0) vs matrix(1,0,0,1,-3,0); the chevron toggle computes to matrix(-1,0,0,-1,0,0) when open.
  • transition-property: --lody-icon-t / 0.32s is on the svg, and flipping the property live interpolates the divider: -1.56 → -2.45 → -2.82 → -2.97 → -3 over ~330 ms on the standard ease. So the transition works in Chromium, not only the two ends.

One finding about the harness rather than this PR: the static Storybook build does not link the StyleX stylesheet (assets/600-*.css) on the gallery story, so every section of the board renders unstyled until that file is injected; the check above injects it. The first build also died on the default Node heap (NODE_OPTIONS=--max-old-space-size=12288 fixes it).

🤖 Generated with Claude Code

Copy link
Copy Markdown
Member Author

Sidebar toggle reworked after review: the collapsed state no longer pastes a chevron in the content area. The open icon has a wider panel with three rows; collapsing slides the divider 10 → 6.5 and the rows shrink into the rail as dots (transform only), so nothing is added for a state and the static sidebar / sidebar-collapsed icons are the two ends of the same drawing.

🤖 Generated with Claude Code

* feat(ui): add the @lody/ui Toggle, ToggleGroup and Toolbar

A control that stays pressed, a set of them answering to one value, and the
bar that holds them, sharing one `toggle` token group.

On is the well rather than ink. The rules give a stored state ink, and ink is
what a control that already sits in a well becomes when it is on — a Switch's
off state occupies the well, so on has to leave it. A toggle rests on nothing,
so the well is free, and sinking into it is the plainest thing this system can
say about a button that went down and stayed. It also keeps a bar of eight from
reading as eight primary buttons.

A set is not a Tabs strip with its track removed: a strip picks what you see,
so it is one control with one pill sliding across it, while a set stores what
is on and two of its members can be pressed at once. A bar draws nothing at all
— not even the line a table draws — and exists to make a row of controls one
tab stop the arrow keys walk.

Migrates the task body's format bar and the tasks view's "Show" chips, and
deletes the dead Radix `toggle.tsx` and its dependency.

Model: claude-opus-5[1m]

* docs(ui): link the toggle family note to its PR

Model: claude-opus-5[1m]
An original 75-icon set drawn in the package on one 24 grid at a 1.5
stroke, following Nucleo's drawing conventions and none of its paths.
`src/icons/registry.ts` holds each drawing as outline marks plus, where
it has depth, a layer model (mass, mid, front, detail, outer, dots);
`Icon` renders it in four treatments — outline, duotone, glyph and bulk —
from those same paths, with a glyph's cuts made through a mask so they
are holes rather than a painted panel colour. Nine stateful icons move
between two states by one registered custom property, `--lody-icon-t`,
that CSS transitions: transform, opacity and dash offset only, no path
morphing, so at rest each state is the static drawing.

Board section, tests, README row, AGENTS.md rule, spec section and a
proposed Agent Note. No product caller migrates yet.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
wibus-wee and others added 10 commits September 16, 2026 01:10
The draft-02 design board (`index.html`) the icon registry was generated
from, and a proposed note from a parallel exploration of a wider icon
family. Committed as found in the worktree so the exploration travels
with the PR; neither is consumed by the package.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`index.html` was not the board the registry was generated from, as the
previous commit claimed: it was a 42-symbol snapshot of the draft in
progress (33 icons plus the model candidates), copied to the worktree
root by a parallel session. The registry was generated from the finished
75-icon board, which `test/icons.test.tsx` and the Agent Note describe;
the board itself is a design artifact and stays out of the tree.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hevron

The collapsed state of the sidebar toggle added a chevron in the content
area, which is a symbol about the sidebar rather than the sidebar. Now
nothing is added for a state: the open icon has a wider panel with three
rows, and collapsing slides the divider from 10 to 6.5 while the rows
shrink into the rail as dots — a narrow sidebar showing only its marks.
The static `sidebar` and `sidebar-collapsed` icons are the two ends of
the same drawing.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The set landed as four more rows on the token board, which is reached
through `packages/components`' Storybook. That was wrong twice. The
board's question is "what is this token's value", answered once per token
off the rendered node; an icon has no token, and its question is asked 75
times — find the drawing, put it at the size the surface uses, in that
surface's colour, on the rung it will sit on, and take it away as markup.
Those are controls, not samples. And the set is `@lody/ui`'s, while
Storybook belongs to a package that only consumes it: looking at an icon
should not require building the product's component library.

So `packages/ui/playground` is a Vite page of its own, `pnpm --filter
@lody/ui playground`, with no Storybook under it: search, a size slider
from 12 to 64, the four treatments, the semantic tones, the four rungs —
the accent rung is where a glyph's mask shows, since the cut is a hole
and the rung shows through — a per-icon panel with the drawing enlarged
on the 24 grid and `copy svg`, and the stateful icons under a slow-motion
switch that lets `--lody-icon-t` be watched rather than inferred from its
two ends. `src/gallery/gallery.tsx` goes back to what it was.

Nothing in `playground/` is exported and the package still depends on
React, Base UI and StyleX alone at runtime; `vite` and
`@vitejs/plugin-react` join its devDependencies. `playground.css` carries
the page's own reset and the slow-motion override, and also gives the
StyleX injection target a chunk to exist in: with no CSS of its own the
bundle emits no `index-<hash>.css` and the built page comes out unstyled.

Typecheck, the production build and the dev server were run. The page
itself has not been opened in a browser; the note says so.

Model: claude-opus-5[1m]
…arks

The collapsed state put its divider at 6.5, which leaves a 3-unit rail.
The two strokes bounding that rail take 1.5 of it, so the marks inside
had 1.5 units to live in and were drawn 2 wide: they overlapped the frame
on one side and the divider on the other, and the icon read as three
lumps. The playground is what showed it.

The animated state was wrong a second way. It shrank the rows with
`scaleX`, and a horizontal scale leaves a stroke's thickness alone while
squashing its round caps, so at t = 1 the marks were 0.8 wide against the
static drawing's 2. The two ends of the transition were two different
drawings, which is the one thing a stateful icon here may not be.

The divider now collapses to 8.5: the rail is 5 units and the 3.5 between
its strokes holds one mark of 2 with 0.75 of air either side. The rows
retract by their dash instead of scaling — a dash shortened to zero length
is a round cap and nothing else, which is a dot — and the uniform scale
carrying it from 1.5 to 2 grows the stroke without touching its shape.
Two marks, not three: at 2 wide a third leaves a 1-unit gap, and a size
ladder shows three smearing into one vertical stroke by 20px while two
stay separate. The middle row fades; the outer two land on (6, 9.5) and
(6, 14.5), which is what `sidebar-collapsed` draws.

Checked in the playground in Chromium. The animated t = 1 and the static
drawing were screenshotted at 64px and compared pixel by pixel: 825 ink
pixels against 833, differing only by a one-pixel fringe in one direction
on every stroke, which is a sub-pixel crop offset and not a difference in
shape. The transition was rendered at t = 0, 0.25, 0.5, 0.75 and 1 — the
caps stay round throughout and no sliver appears. The note records the
correction and retires the `translateX(-3px)` measurement, which was read
off the broken drawing.

Model: claude-opus-5[1m]
Drawn with an 11-wide dome over 6 units of straight side the bell was 14
across and 13 down: a tube with a cap on it, not a bell. The dome is 12
now and the side sweeps out to a mouth 16.4 across a 12.7-tall body.

The mouth is the part worth reading. Widening it while it was still a
flange — a straight flare from the side down to the mouth line — made the
filled variants grow horns: the flare met the mouth at 39 degrees, and a
39-degree tip is a spike that `stroke-linejoin: round` hides in the
outline and a fill cannot. Rounding the spike is not available either,
since a 0.8 radius at that angle consumes 2.28 units of a 3.2-unit flare
and there is no flange left. So the flange is gone. The side arrives at
the mouth vertically through a curve, turns through a 0.7 corner, and the
mouth is a line under it — a 90-degree corner, which a fill can hold.
That is the general form of a rule the set already had for glyphs: what
the outline's stroke rounds, the mass has to round itself.

The clapper's arc is a segment rather than a half-circle, so it hangs a
unit clear of the mouth and stops at the live area instead of 1.25 past
it; `front` is that same silhouette filled, which is what gives bulk a
solid clapper under the 35% body.

A wider bell swings wider. The mouth is 16 units from the nub it pivots
on, and at the old 14 degrees the corner furthest from the pivot put its
stroke 0.03 from the canvas — inside, but not a margin. The swing is 12
degrees, which leaves 0.42 and reads the same.

`BellRingIcon` no longer restates the path: it reads `ICONS.bell`, and a
test holds it there. That is the guard the sidebar did not have, and both
of this branch's drawing bugs were a stateful icon and a static one
drifting apart.

Seen in the playground in all four treatments at 12 through 48, and the
swing rendered at -12, -6, 0, 6 and 12 degrees against the canvas edge.
`pnpm --filter @lody/ui test` passes, 262 tests.

Model: claude-opus-5[1m]
`packages/components` imports 239 distinct icons from `lucide-react` across
295 files, 2138 usages. Collapsed onto the drawings that would serve them,
the set covered 78% of that. These sixteen take it to 91%: monitor,
alert-circle, users, circle, shield-alert, folder-plus, arrow-up, mail,
wrench, undo, quote, save, image, pin-off, fork and pull-request-closed.
`monitor` alone answers 52 usages, which is what a product about machines
looks like from the icon layer.

Each takes its family's skeleton before it takes a better idea: `monitor`
and `image` the 2px container `sidebar` and `terminal` already share,
`alert-circle` the circle family's ring with `info-circle` turned over,
`shield-alert` the shield, `users` `user` twice, `fork` and
`pull-request-closed` the git family's two node columns and 2px nodes.
`send` and `arrow-up`, and `pin` and `pin-off`, are one drawing under two
names: the path is a module const rather than two copies, which is the
lesson of the two drift bugs earlier on this branch.

`package` was dropped from the batch rather than drawn — a box with a lid
is `archive` and an isometric one is `model`.

Three were redrawn after the first pass, all failing the same way, reading
at 120px and not at 20. `quote` as a block with the tail notched out of a
corner is a pair of counters by 20px; it is a hook now. `pin-off` broken
into fragments is not a pin, so the pin stays whole and takes the line
across it. `pull-request-closed` had its cross floating clear of the branch
it closes.

`folder-plus` and `users` ended up with no layer model, which is a rule
worth having: **a drawing a fill would cost its meaning takes no layers.**
A cross on a 100% front panel is currentColor on currentColor and what is
left is `folder`; half a person as a stroke beside a filled one is two
fragments off a silhouette. `folder-open` and `user` were already
outline-only for the same reason. The playground's bulk column is where
both showed.

The icon rules move to `src/icons/AGENTS.md`: the parent was 33 bytes under
its gate and these are now a scope of their own, with the grid, the four
treatments, the two-state rules and what to check before calling a drawing
done. Families are reordered so members line up in a list, which is what
that file asks for.

Seen in the playground at 32 and 52 in outline, glyph and bulk.
`pnpm --filter @lody/ui test` passes, 262 tests.

Model: claude-opus-5[1m]
A second reading of the board sent six back, and the reasons divide into
three kinds.

**A shape that is not the thing.** `quote`, twice: a notched block reads as
a counter, and a ball with its tail swept too far round it reads as a
lowercase e. What a quote mark is is a ball with a short tail, so that is
what it is now.

**A proportion that is not the thing's.** `save` at 17 by 15 is a
letterbox; a disk is square, so it is 15 by 15, and its shutter and label
centre on 12. `code` had 4.5 of chevron travel and a slash 4 wide, which
bunched its mass in the middle and read narrow beside everything on its
row; the chevrons reach the live area now and the slash leans across it.

**A part that does not belong to the whole.** `folder-plus` carried the
front-panel line as well as the cross, and that line splits the face the
cross has to sit in — the line is gone and the cross centres on the face.
`pull-request-closed` drew its cross at an arrowhead's size with the branch
stopping two units short of it, so the cross and the branch read as two
drawings sharing a column; the cross is a node's size now and the branch
runs up to meet it. And `fork` put its heads on 6.5 and 17.5 while
`branch`, `merge` and `pull-request` put theirs on 7 and 17 — symmetrical
on its own, out of line the moment the family is read down a list, which is
the only way a family is ever read.

Four of the six read fine enlarged and failed at 20, which is the size they
will be used at. No test sees any of this.

Seen in the playground: all six at 112px on the grid, the git family side
by side against its 7 and 17 columns, and `save` in all four treatments at
16 through 32. `pnpm --filter @lody/ui test` passes, 262 tests.

Model: claude-opus-5[1m]
It was substituted into the batch when `package` turned out to be
`archive`, and it went through four drawings without arriving: a cross
capping the branch, which leaves two columns with nothing between them and
reads as two lollipops rather than a pull request; the branch run up into
the cross to close that gap, which came out a spear; `pull-request`'s arm
restored with a cross for its arrowhead, drawn at the size that fit the
room left above the arm rather than the size a cross is in this set; and
the same again at `x-circle`'s 5. The last was defensible and still not
good enough to keep.

The lesson is about the batch rather than the drawing. It answers four
usages; everything else in the fifteen answers seven or more, and none of
them needed more than one redraw. A drawing that has to say a relation did
not happen — not a thing, not a state — is a harder problem than four
usages justify, and the honest move when `package` fell out was to draw
fifteen, not to fill the slot from the ranked list because it was open.

`packages/components` keeps `lucide-react`'s `GitPullRequestClosed` at
those four sites, as it does for every icon this set has not taken over; no
caller changes. 90 icons.

Model: claude-opus-5[1m]
wibus-wee and others added 2 commits September 16, 2026 05:08
The gallery file was missing Toggle component definitions and rows during the rebase.
Restored from origin/feat/ui-v2 to include all necessary Toggle, ToggleGroup,
and Toolbar components and their gallery representations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants